import { BufferGeometryLoader3D, Color, Euler, Material3D, Matrix4, MeshNormalMaterial3D, Node3D, PerspectiveCamera3D, Quaternion, Scene3D, StatsComponent, Vector3, WebGLRenderer3D } from 'feng3d-three';
import GUI from 'lil-gui';
import '../../extension/controls/OrbitControls3D';
import { OrbitControls3D } from '../../extension/controls/OrbitControls3D';
import { BufferGeometryUtils3D } from '../../extension/utils/BufferGeometryUtils3D';

const rootPath = '../../';

let container: HTMLElement;
let stats: StatsComponent;
let gui: GUI;
let guiStatsEl: HTMLDivElement;
let camera: PerspectiveCamera3D;
let controls: OrbitControls3D;
let scene: Scene3D;
let renderer: WebGLRenderer3D;
let material: Material3D;

// gui

const Method = {
    INSTANCED: 'INSTANCED',
    MERGED: 'MERGED',
    NAIVE: 'NAIVE'
};

const api = {
    method: Method.INSTANCED,
    count: 1000
};

//

init();
initMesh();
animate();

//

function clean()
{
    const meshes: Node3D[] = [];

    scene.entity.traverse(function (object: Node3D)
    {
        if (object.isMesh) meshes.push(object);
    });

    for (let i = 0; i < meshes.length; i++)
    {
        const mesh = meshes[i].getComponent('Mesh3D');
        mesh.material.dispose();
        mesh.geometry.dispose();

        scene.entity.removeChild(mesh.entity);
    }
}

const randomizeMatrix = (function ()
{
    const position = new Vector3();
    const rotation = new Euler();
    const quaternion = new Quaternion();
    const scale = new Vector3();

    return function (matrix: Matrix4)
    {
        position.x = Math.random() * 40 - 20;
        position.y = Math.random() * 40 - 20;
        position.z = Math.random() * 40 - 20;

        rotation.x = Math.random() * 2 * Math.PI;
        rotation.y = Math.random() * 2 * Math.PI;
        rotation.z = Math.random() * 2 * Math.PI;

        quaternion.setFromEuler(rotation);

        scale.x = scale.y = scale.z = Number(Math.random());

        matrix.compose(position, quaternion, scale);
    };
})();

function initMesh()
{
    clean();

    // make instances
    new BufferGeometryLoader3D()
        .setPath(`${rootPath}models/json/`)
        .load('suzanne_buffergeometry.json', function (geometry)
        {
            material = new MeshNormalMaterial3D();

            geometry.computeVertexNormals();

            console.time(`${api.method} (build)`);

            switch (api.method)
            {
                case Method.INSTANCED:
                    makeInstanced(geometry);
                    break;

                case Method.MERGED:
                    makeMerged(geometry);
                    break;

                case Method.NAIVE:
                    makeNaive(geometry);
                    break;
            }

            console.timeEnd(`${api.method} (build)`);
        });
}

function makeInstanced(geometry)
{
    const matrix = new Matrix4();
    const mesh = new Node3D().addComponent('InstancedMesh3D', { geometry, material, count: api.count });

    for (let i = 0; i < api.count; i++)
    {
        randomizeMatrix(matrix);
        mesh.setMatrixAt(i, matrix);
    }

    scene.entity.addChild(mesh.entity);

    //

    const geometryByteLength = getGeometryByteLength(geometry);

    guiStatsEl.innerHTML = [

        '<i>GPU draw calls</i>: 1',
        `<i>GPU memory</i>: ${formatBytes(api.count * 16 + geometryByteLength, 2)}`

    ].join('<br/>');
}

function makeMerged(geometry)
{
    const geometries = [];
    const matrix = new Matrix4();

    for (let i = 0; i < api.count; i++)
    {
        randomizeMatrix(matrix);

        const instanceGeometry = geometry.clone();
        instanceGeometry.applyMatrix4(matrix);

        geometries.push(instanceGeometry);
    }

    const mergedGeometry = BufferGeometryUtils3D.mergeBufferGeometries(geometries);

    scene.entity.addChild(new Node3D().addComponent('Mesh3D', { geometry: mergedGeometry, material }).entity);

    //

    guiStatsEl.innerHTML = [

        '<i>GPU draw calls</i>: 1',
        `<i>GPU memory</i>: ${formatBytes(getGeometryByteLength(mergedGeometry), 2)}`

    ].join('<br/>');
}

function makeNaive(geometry)
{
    const matrix = new Matrix4();

    for (let i = 0; i < api.count; i++)
    {
        randomizeMatrix(matrix);

        const mesh = new Node3D().addComponent('Mesh3D', { geometry, material });
        mesh.entity.applyMatrix4(matrix);

        scene.entity.addChild(mesh.entity);
    }

    //

    const geometryByteLength = getGeometryByteLength(geometry);

    guiStatsEl.innerHTML = [

        `<i>GPU draw calls</i>: ${api.count}`,
        `<i>GPU memory</i>: ${formatBytes(api.count * 16 + geometryByteLength, 2)}`

    ].join('<br/>');
}

function init()
{
    const width = window.innerWidth;
    const height = window.innerHeight;

    // camera

    camera = new Node3D().addComponent('PerspectiveCamera3D', { fov: 70, aspect: width / height, near: 1, far: 100 });
    camera.entity.position.z = 30;

    // renderer

    renderer = new Node3D().addComponent('WebGLRenderer3D', { parameters: { antialias: true } });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(width, height);
    renderer.outputEncoding = 'sRGBEncoding';

    container = document.getElementById('container');
    container.appendChild(renderer.domElement);

    // scene

    scene = renderer.addComponent('Scene3D');
    scene.background = new Color(0xffffff);

    // controls

    controls = new Node3D().addComponent('OrbitControls3D', { camera, domElement: renderer.domElement });
    controls.autoRotate = true;

    // stats

    stats = scene.addComponent('StatsComponent');
    container.appendChild(stats.dom);

    // gui

    gui = new GUI();
    gui.add(api, 'method', Method).onChange(initMesh);
    gui.add(api, 'count', 1, 10000).step(1).onChange(initMesh);

    const perfFolder = gui.addFolder('Performance');

    guiStatsEl = document.createElement('div');
    guiStatsEl.classList.add('gui-stats');

    perfFolder.$children.appendChild(guiStatsEl);
    perfFolder.open();

    // listeners

    window.addEventListener('resize', onWindowResize);

    Object.assign(window, { scene });
}

//

function onWindowResize()
{
    const width = window.innerWidth;
    const height = window.innerHeight;

    camera.aspect = width / height;
    camera.updateProjectionMatrix();

    renderer.setSize(width, height);
}

function animate()
{
    requestAnimationFrame(animate);

    controls.update();
    stats.update();

    render();
}

function render()
{
    renderer.render(scene, camera);
}

//

function getGeometryByteLength(geometry)
{
    let total = 0;

    if (geometry.index) total += geometry.index.array.byteLength;

    for (const name in geometry.attributes)
    {
        total += geometry.attributes[name].array.byteLength;
    }

    return total;
}

// Source: https://stackoverflow.com/a/18650828/1314762
function formatBytes(bytes: number, decimals: number)
{
    if (bytes === 0) return '0 bytes';

    const k = 1024;
    const dm = decimals < 0 ? 0 : decimals;
    const sizes = ['bytes', 'KB', 'MB'];

    const i = Math.floor(Math.log(bytes) / Math.log(k));

    return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
